로딩 중이에요... 🐣
24 보안 | ✅ 저자: 이유정(박사)
JWT란? JWT (JSON Web Token) 는 이런 인증 토큰을 JSON 형식으로 만들어 암호화한 것입니다. 구조는 3가지 조각으로 나뉩니다:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyMSIsImV4cCI6MTYzNzk5Njk4Mn0.
7kAgCj...암호화된서명...
Header
토큰의 종류, 사용한 암호화 알고리즘
Payload
사용자 정보 (ex: id, 이름, 권한 등)
Signature
암호화된 서명 (위조 방지용)
JWT vs 일반 토큰 (Session Token 등) 비교
항목 | 일반 토큰 (세션 기반) | JWT (JSON Web Token) |
---|---|---|
저장 위치 | 서버 메모리 (세션 스토리지) | 클라이언트에 저장 |
서버에서 토큰 관리 | 필요함 (세션 테이블 유지) | 필요 없음 (Stateless) |
구조 | 단순 문자열 (의미 없음) | 구조화된 JSON + 서명 |
위변조 방지 | 불가능 (서명 없음) | 가능 (서명 포함, 변조 시 검증 실패) |
확장성 | 서버가 커지면 세션 동기화 문제 생김 | 수평 확장에 유리 (Stateless 구조) |
단점 | 서버 메모리 부담, 유지 필요 | 탈취당하면 누구나 사용 가능 (주의 필요) |
보안 측면에서 JWT는 왜 유리할까?
-
위조 방지:
JWT는 서명이 포함되어 있어, 누가 내용을 바꾸면 검증에 실패합니다. -
서버가 기억 안 해도 됨:
사용자가 보내는 토큰만 있으면, 다시 로그인 없이 확인 가능.
서버는 "이 토큰이 위조된 게 아닌지만" 확인하면 됨. -
토큰 안에 정보 포함 가능:
예:{"username": "eunice", "role": "admin"}
→ 권한, 유저 정보도 포함 가능
JWT는 "서명된 JSON 토큰"으로, 서버가 사용자를 식별하고 권한을 판단할 수 있게 해주는 위조 불가능한 인증표입니다.
일반 토큰보다 보안성과 확장성이 뛰어나며, 서버에 상태를 저장하지 않아도 됩니다.
Django나 Google API, Amazon Cloud에서 내려받는 보안키(비밀 키, API 키 등)와는 다른 역할:
항목 | JWT | 보안 키(API Key / Secret Key) |
---|---|---|
정의 | 사용자 정보를 담고 있는 토큰 (디지털 신분증) | 서버끼리 통신 시, 인증을 위한 비밀키 |
형태 | aaaaa.bbbbb.ccccc 같은 문자열 |
sk_test_xxxxx / AIzaSy... 같은 문자열 |
누가 만듬 | 서버가 사용자 로그인 후 발급 | 서비스 제공자(Google, AWS 등)가 발급 |
용도 | 로그인된 유저의 상태를 확인 | 외부 API 인증(예: Stripe, Google Maps 등) |
보관 위치 | 클라이언트(브라우저/앱)에 저장 | 서버(환경변수 등)에만 저장 |
예시 | FastAPI에서 로그인 후 JWT 발급 | .env 에 SECRET_KEY=xxxxx 등 저장 |
패키지 설치
pip install fastapi uvicorn python-jose[cryptography] passlib[bcrypt]
디렉토리 구조 (간단 예시)
project/
│
├── main.py
├── auth.py
└── users.py
JWT 관련 설정 (auth.py
)
from fastapi import FastAPI, HTTPException, Depends, status, Form
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from passlib.context import CryptContext
from jose import JWTError, jwt, ExpiredSignatureError
from datetime import datetime, timedelta, timezone
from typing import Optional
from pydantic import BaseModel
import os
from dotenv import load_dotenv
# ===============================
# 📌 설정
# ===============================
load_dotenv() # .env 파일 로드
SECRET_KEY = os.getenv("SECRET_KEY", "mysecretkey") # .env에서 가져오거나 기본값 사용
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
app = FastAPI()
# ===============================
# 📌 유저 모델 (Pydantic)
# ===============================
class User(BaseModel):
username: str
class UserInDB(User):
hashed_password: str
# ===============================
# 📌 유저 저장소 (예제용)
# ===============================
fake_user_db = {
"testuser": UserInDB(
username="testuser",
hashed_password=pwd_context.hash("test123") # 앱 시작 시 해싱
)
}
# ===============================
# 📌 유틸 함수들
# ===============================
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def authenticate_user(username: str, password: str) -> Optional[UserInDB]:
user = fake_user_db.get(username)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
def create_access_token(data: dict, expires_delta: timedelta = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire, "sub": data.get("sub")})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
# ===============================
# 📌 토큰 검증
# ===============================
def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
user = fake_user_db.get(username)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user
except ExpiredSignatureError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
✅ .env 예시 파일
SECRET_KEY=your_super_secret_key_here
위 코드를 실행하려면 실제 .env 파일을 프로젝트 루트에 두고 SECRET_KEY를 작성해야 합니다.
⚠️ 실제 운영 환경에서는 OpenSSL 등을 이용해 생성한 복잡한 키를 사용하세요.
⚠️ 예: openssl rand -hex 32
FastAPI 앱 구성 (main.py
)
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from auth import User, authenticate_user, create_access_token, get_current_user
app = FastAPI()
@app.post("/token")
def login(form_data: OAuth2PasswordRequestForm = Depends()):
user = authenticate_user(form_data.username, form_data.password)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password")
access_token = create_access_token(data={"sub": user.username})
return {"access_token": access_token, "token_type": "bearer"}
# ===============================
# 📌 보호된 라우트
# ===============================
@app.get("/protected")
def read_users_me(current_user: User = Depends(get_current_user)):
return {"username": current_user.username}
테스트 http
# 🔐 auth.http - FastAPI 인증 테스트 파일
##
@access_token = 발급토큰입력
### ✅ 1. 토큰 발급 요청 (로그인)
POST http://127.0.0.1:8000/token
Content-Type: application/x-www-form-urlencoded
username=testuser&password=test123
### ✅ 2. 보호된 API 호출 - 내 정보 가져오기 (/users/me)
GET http://127.0.0.1:8000/protected
Authorization: Bearer {{access_token}}
테스트 순서 로그인 (토큰 발급)
curl -X POST "http://127.0.0.1:8000/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=testuser&password=test123"
응답:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer"
}
보호된 경로 접근 테스트
curl -X GET "http://127.0.0.1:8000/protected" \
-H "Authorization: Bearer <위에서 받은 access_token>"
응답:
{
"msg": "Hello testuser, you're authenticated!"
}
-
/token
으로 로그인 요청 (username/password) -
서버는 JWT 토큰을 발급
-
/protected
요청 시 토큰이 있어야 접근 가능 -
SECRET_KEY
는 환경변수로 관리하세요. -
토큰 탈취 방지 위해 HTTPS + 토큰 저장 위치 고려 (쿠키 or localStorage)